查看原文
其他

代码实例详解用BiLSTM-CRF模型进行实体抽取【珠峰书 知识图谱 深度学习 NER】

走向未来 走向未来 2023-08-08


这一波人工智能的繁荣发展,深度学习功不可没,几乎可以肯定地说,没有深度学习,就没有今天人工智能的欣欣向荣。自然而然的,深度学习在各个人工智能子领域迅速渗透,自然语言处理和知识图谱也不例外地大量使用深度学习方法。在实体抽取方面,深度学习同样表现卓绝,已成为当前最常用的和效果最优的方法。本文介绍了实体抽取中最经典的深度学习模型——BiLSTM-CRF模型。


在前面几篇已经介绍了实体抽取的多种其他方法,本文所介绍的BiLSTM-CRF也是珠峰书《知识图谱:认知智能理论与实战》配套系列文章。跟随着这篇文章,能够学习到如何使用经典的深度学习模型BiLSTM-CRF来进行实体抽取。进一步的,通过本文,大家也可以看到深度学习的奇妙之处,进而加深对深度学习的了解。


BiLSTM-CRF 模型


BiLSTM-CRF(双向长短期记忆网络-条件随机场)模型在实体抽取任务中用得最多,是实体抽取任务中深度学习模型评测的基准,也是在BERT出现之前最好用的模型。在使用CRF进行实体抽取时,需要专家利用特征工程设计合适的特征函数,比如CRF++中的特征模板文件。BiLSTM-CRF则不需要利用特征工程,而是通过BiLSTM网络自动地从数据(训练语料)中学习出特征,并通过CRF计算标签的全局概率信息对输出词元序列进行解码,得到对应的标签序列。这正是深度学习方法相比于传统机器学习方法所具有的巨大优势。深度学习方法将算法人员从琐碎的特征工程中解放出来,专注于深度神经网络结构的创新,进而形成正向循环,推动了人工智能近年来的高速发展。

——珠峰书《知识图谱:认知智能理论与实战》 P117



图1是BiLSTM-CRF 模型的结构图,珠峰书《知识图谱:认知智能理论与实战》 P118~122详细解析了LSTM、BiLSTM-CRF模型的理论,想深入了解的读者可参阅相关章节。



图1 BiLSTM-CRF模型结构图,引用自珠峰书《知识图谱:认知智能理论与实战》图3-13 P118


使用飞桨(Paddle)的准备工作



飞桨框架在1.x 版本使用了静态图,并在 fluid 模块中提供了 CRF 有关的组件。但在飞桨2.x 中改为了动态图,并提醒 fluid 模块会被舍弃。

在珠峰书中,限于篇幅,并且以示例为目的,避免引入过多的其他依赖库,在CRF中选择使用了 paddle 的 fluid 模块的 CRF 组件。不过本文选用了飞桨2.x 的动态图模型构建方法,并因此引入了额外的PaddleNLP 模块。有关 PaddleNLP 可参考官方文档:

https://paddlenlp.readthedocs.io

import numpy as npimport paddlefrom paddle import nnfrom paddle.callbacks import VisualDL, EarlyStopping, ReduceLROnPlateauimport paddlenlpfrom paddlenlp.layers import LinearChainCrf, LinearChainCrfLoss, ViterbiDecoderprint(paddle.__version__, paddlenlp.__version__)# '2.3.2', '2.3.7'



数据准备


本例子使用 MSRA 发布的公开命名实体识别的语料,可以从【 https://github.com/wgwang/kg-book/tree/main/datasets/NER-MSRA 】下载处理好的数据集,包括训练语料train.txt和测试语料test.txt。语料的说明见该目录下的 readme。该语料相关内容也可参阅《CRF++进行实体抽取》一文的详细介绍。


读取数据


c2i = {'\001': 0}t2i = {'O':0}
def read_data(filename, token2id, label2id): '''读入训练语料,并转化为id 格式为适合crf++的格式: 每行格式为 token\t标签 空行表示句子结束 @param filename: 语料文件名 @param token2id: 词元到 id 的映射字典 @param label2id:标签到 id 的映射字典 ''' data = [] max_word_id = max([v for k, v in token2id.items()]) + 1 max_label_id = max([v for k, v in label2id.items()]) + 1
sent = [] lbl = [] with open(filename) as f: for line in f: line = line.strip() if not line: data.append((sent, lbl)) sent = [] lbl = [] continue c, t = line.split("\t") if c not in c2i: c2i[c] = max_word_id max_word_id += 1            ci = c2i[c] if t not in t2i: t2i[t] = max_label_id max_label_id += 1            ti = t2i[t] sent.append(ci) lbl.append(ti) if sent: data.append((sent, lbl)) return data

train_data = read_data('./msra/train.txt', c2i, t2i)print('train: ', len(train_data))test_data = read_data('./msra/test.txt', c2i, t2i)print('test: ', len(test_data))

print('words count:', len(c2i))print('labels count:', len(t2i))
# train: 45057# test: 3442# words count: 4856# labels count: 7


标签情况


print(t2i)


标签输出:

{'O': 0, 'B-LOC': 1, 'I-LOC': 2, 'B-ORG': 3, 'I-ORG': 4, 'B-PER': 5, 'I-PER': 6}


Paddle的Dataset


将数据集转化为 Paddle 的 Dataset 格式,方便后续给模型使用

  • paddle.io.Dataset 是Paddle数据集的抽象类,需要实现如下两个方法:

  • __getitem__: 根据给定索引获取数据集中指定样本,在 paddle.io.DataLoader 中需要使用此函数通过下标获取样本。
  • __len__: 返回数据集样本个数, paddle.io.BatchSampler`中需要样本个数生成下标序列。


关于 paddle.io.Dataset的详细内容,参考:https://www.paddlepaddle.org.cn/documentation/docs/zh/api/paddle/io/Dataset_cn.html



class TheDataset(paddle.io.Dataset): def __init__(self, data): self.data = data

def __getitem__(self, idx): sent, lbl, length = self.data[idx] return sent, length, lbl def __len__(self): return len(self.data)



转化为 Paddle 的 Dataset


def trans2TheDataset(data, max_seq_len): data2 = [] for k, v in data: klen = min(len(k), max_seq_len) if len(k) < max_seq_len: pad = [0] * (max_seq_len - len(k)) k = k + pad v = v + pad elif len(k) > max_seq_len: k = k[:max_seq_len] v = v[:max_seq_len] data2 .append((np.asarray(k, dtype='int64'), np.asarray(v, dtype='int64'), np.asarray(klen, dtype='int64'))) return TheDataset(data2)


创建数据集



由于 msra 数据集仅提供了 train 和 test,没有 dev 数据集。这里将 train 进行二八划分为 dev 和 train 两个数据集。

另外,在输入中需要对过长的输入句子进行截断,这里设置max_seq_len为255,在实际应用中可根据情况取值。

max_seq_len = 255
# train_data 拆分为 train 和 devtrain_data_count = len(train_data)dev_data_count = int(train_data_count * 0.2)train_data_count -= dev_data_countprint(train_data_count, dev_data_count)dev_data = train_data[:dev_data_count]train_data = train_data[dev_data_count:]print(len(train_data), len(dev_data))
train_dataset = trans2TheDataset(train_data, max_seq_len)dev_dataset = trans2TheDataset(dev_data, max_seq_len)test_dataset = trans2TheDataset(test_data, max_seq_len)
print(len(train_dataset), len(dev_dataset), len(test_dataset))


输出:

36046 901136046 901136046 9011 3442



构建支持获取微批数据的 Dataloader

l`DataLoader`返回一个迭代器,该迭代器根据 batch_sampler 给定的顺序迭代一次给定的 dataset

l `DataLoader`支持单进程和多进程的数据加载方式,当 num_workers 大于0时,将使用多进程方式异步加载数据。


有关 Dataloader 的详细内容, 参考:https://www.paddlepaddle.org.cn/documentation/docs/zh/api/paddle/io/DataLoader_cn.html


batch_size = 32# 加载数据train_loader = paddle.io.DataLoader(train_dataset, shuffle=True, batch_size=batch_size, drop_last=True)dev_loader = paddle.io.DataLoader(dev_dataset, shuffle=False, batch_size=batch_size*2, drop_last=False)test_loader = paddle.io.DataLoader(test_dataset, shuffle=False, batch_size=batch_size*8, drop_last=False)

for b in train_loader: breakx, l, y = bx.shape, l.shape, y.shape

输出:

([32, 255], [32], [32, 255])



构建BiLSTM-CRF实体抽取模型



在《知识图谱:认知智能理论与实战》一书中,对实体的定义为:


实体(Entity):是指一种独立的、拥有清晰特征的、能够区别于其他事物的事物。在信息抽取、自然语言处理和知识图谱等领域,用来描述这些事物的信息即实体。实体可以是抽象的或者具体的。

——王文广 《知识图谱:认知智能理论与实战》 P81



这是对1996年MUC-6会议对命名实体的扩展。MUC组委会在当时提出的“命名实体”任务要求从文本中识别出所有的人物名称(人名)、组织机构名称(机构名)和地理位置名称(地名),以及时间、货币和百分数的表述。如果仅仅识别人名、地名、机构名等实体的话,常见的分词库(如 jieba、HanLP、LAC 等)都支持的,可以直接使用这些库来识别,效果通常还不错。

而如果要在产业应用中进行实体抽取,仅仅能够处理这几个命名实体则远远不够。比如书名的识别、建筑物名称的识别、汽车品牌的识别、汽车零部件的识别等等。

在实践中,实体不一定是对物理事物的表述,也可以是对虚拟事物的表述。比如“经济指标”类型的实体“CPI”、人物或者组织机构发表的“观点”类型的实体、某个领域权威人物发表的“言论”类型的实体,在制造业质量和可靠性工程中的“失效事件”类型的实体,以及在各类机械与电子电器设备制造领域中的“性能”类型的实体等。

——王文广 《知识图谱:认知智能理论与实战》 P81



实体抽取(命名实体识别)就是从一段文本中抽取出符合要求的实体,常见的实体抽取方法非常多,在《知识图谱:认知智能理论与实战》的第三章介绍了主流的几种实体抽取方法。下面的模型来自于该书(珠峰书)3.5.2节《BiLSTM-CRF 模型》,详细内容参考珠峰书《知识图谱:认知智能理论与实战》一书P117~122。

值得一提的是,本文使用动态图模式构建**BiLSTM-CRF**模型。模型的结构与珠峰书清单3-17(P120)基本一致,不同的是书中使用了paddle.fluid模块,使用了静态图的方式。下面的模型则使用了 PaddleNLP 模块,使用了动态图的方式。模型的详细解析可参考珠峰书 P120~122.

PaddleNLP 中有关 CRF 的模块,参考:https://paddlenlp.readthedocs.io/zh/latest/source/paddlenlp.layers.crf.html

值得说明的是,在`LinearChainCrf`中有个参数`with_start_stop_tag`,默认为`Tru`e,表示额外的两个标签`start_tag`和`stop_tag`,是的`transitions`矩阵的大小为`[num_labels+2, num_labels+2]`,详情参阅上述链接的说明。


# BiLSTM-CRF模型class EEModel(nn.Layer): """用于实体抽取(命名实体识别)的BiLSTM-CRF模型"""

def __init__(self, vocab_size, num_labels, embed_size, hidden_size, lr=0.0001): """ @param vocab_size: 词表大小 @param num_labels: 标签数量 @param embed_size: Embedding层的大小 @param hidden_size: BiLSTM 层的大小 @param lr: CRF的学习率 """ super().__init__() self.lr = lr self.num_labels = num_labels # 嵌入层 self.emb = nn.Embedding(vocab_size, embed_size) # 双向LSTM self.bilstm = nn.LSTM(embed_size, hidden_size, direction='bidirectional') # 全连接层,映射到输出标签空间 # 这里的num_labels+2 见前述说明 self.fc = nn.Linear(hidden_size * 2, num_labels+2) # CRF层 self.crf = LinearChainCrf(self.num_labels, crf_lr=lr) # loss self.loss = LinearChainCrfLoss(self.crf) # 使用维特比解码器求解概率最大的路径 self.crf_decoding = ViterbiDecoder(self.crf.transitions) def forward(self, sents=None, seq_len=None): embedding = self.emb(sents) # bilstm        output, _ = self.bilstm(embedding, sequence_length=seq_len) emission = self.fc(output) # 直接返回解码结果 _, prediction = self.crf_decoding(emission, seq_len) return emission, seq_len, prediction



实例化模型


vocab_size = max([v for k, v in c2i.items()]) + 1num_labels = max([v for k, v in t2i.items()]) + 1
embed_size = 112hidden_size = 160
model = EEModel(vocab_size, num_labels, embed_size, hidden_size, lr=0.0001)



训练准备



learning_rate = 0.001# 学习率预热scheduler = paddle.optimizer.lr.LinearWarmup( learning_rate=learning_rate, warmup_steps=300, start_lr=0.0001, end_lr=learning_rate)

# 优化器opt = paddle.optimizer.AdamW(learning_rate=scheduler, parameters=model.parameters())



# 封装成Modelner_model = paddle.Model(model)

# 训练时的评估指标,按标签评估模型metric = paddlenlp.metrics.ChunkEvaluator(t2i.keys())

# 模型准备,设置loss函数,优化器,评估器ner_model.prepare( loss=model.loss, optimizer=opt, metrics=metric,)

# 模型保存路径save_dir=os.path.abspath("./checkpoint")if not os.path.exists(save_dir): os.mkdir(save_dir)

# 监测loss变化情况,3 epoch内没变化就停止,并保存最佳模型early_stopping = EarlyStopping(monitor="loss", mode="min", patience=3)



模型训练


# 训练ner_model.fit( train_data=train_loader, eval_data=dev_loader, epochs=30, log_freq=300, save_dir=save_dir, callbacks=[early_stopping])



评估效果



在文章《实体抽取:如何评估算法的效果?》中,详细介绍了模型效果的评估,有两种方法:

  • 基于词元的效果评估

  • 基于实体的效果评估


 这里采用基于实体的效果评估



id 到词元/标签

i2c = {v:k for k, v in c2i.items()}i2t = {v:k for k, v in t2i.items()}


从结果中提取实体



def entities_of_sentence(sent, labels, seq_len=None): '''适用于BIO标记方法'''

if type(sent) == str: sent = sent.split() if type(labels) == str: labels = labels.split() if seq_len is None: seq_len = len(sent) entities = {} tokens_of_entity = [] type_of_entity = None idx = 0 while idx < seq_len: label = labels[idx] word = sent[idx] idx += 1 if label == 'O': continue if label.startswith('B'): # print(tokens_of_entity, type_of_entity) if tokens_of_entity: if type_of_entity in entities: entities[type_of_entity].append( ''.join(tokens_of_entity)) else: entities[type_of_entity] = [ ''.join(tokens_of_entity)] tokens_of_entity = [word] # B-type, 比如B-ORG表示ORG类型 type_of_entity = label[2:] continue if label.startswith('I'): # I-type, 比如I-ORG表示ORG类型 if label[2:] != type_of_entity: # B-type 和 I-type不同,说明抽取结果有误 # 删除该抽取结果 tokens_of_entity = [] type_of_entity = None else: tokens_of_entity.append(word) if tokens_of_entity: if type_of_entity in entities: entities[type_of_entity].append( ''.join(tokens_of_entity)) else: entities[type_of_entity] = [ ''.join(tokens_of_entity)] return entities

x, y = test_data[0]x = [i2c[i] for i in x]y = [i2t[i] for i in y]entities_of_sentence(x, y)


计算 F1值



def evaluate_entities(gt, preds): '''计算根据类别加权的宏观F1分数''' f1s = [] for cate in gt.keys(): y = set(gt[cate]) y_hat = set(preds[cate]) y_i = y.intersection(y_hat) p, r, f1 = 0, 0, 0 if y_i: p = len(y_i) / len(y) r = len(y_i) / len(y_hat) f1 = 2 * (p * r) / (p + r) f1s.append(f1) return sum(f1s) / len(f1s)



使用测试集测试




model.eval()

ee_gt = {}ee_pred = {}

with paddle.no_grad(): for sents, sls, labels in test_loader: _, _, preds = model.forward(sents, sls) sls = sls.tolist() for sent, sl, label, pred in zip(sents, sls, labels, preds): sent = [i2c[i] for i in sent.tolist()] label = [i2t[i] for i in label.tolist()] pred = [i2t[i] for i in pred.tolist()]

es_gt = entities_of_sentence(sent, label, sl) for k, v in es_gt.items(): if k not in ee_gt: ee_gt[k] = [] ee_gt[k].extend(v) es_pred = entities_of_sentence(sent, pred, sl) for k, v in es_pred.items(): if k not in ee_pred: ee_pred[k] = [] ee_pred[k].extend(v)


  


计算测试集的 F1值



evaluate_entities(ee_gt, ee_pred)




计算每个类别的 F1值




for cate in ee_gt.keys(): y = set(ee_gt[cate]) y_hat = set(ee_pred[cate]) y_i = y.intersection(y_hat) p, r, f1 = 0, 0, 0 if y_i: p = len(y_i) / len(y) r = len(y_i) / len(y_hat) f1 = 2 * (p * r) / (p + r) print(cate, f1)



总结



本文详细示例了使用飞桨框架构建 BiLSTM-CRF 模型进行实体抽取的过程,配合珠峰书《知识图谱:认知智能理论与实战》使用,读者能够完全理解并在实际项目或业务中使用该模型来抽取实体,进而构建出知识图谱。本文所使用的数据以及完整代码可从https://github.com/wgwang/kg-book上获取。

从这个完整的示例可以看出,使用深度学习的方法进行实体抽取,其好处是不需要特征工程(比如 CRF++中的特征模板设置),仅需要标注好的训练语料即可。在大量的实践中也证实了,通过足够的标注语料,深度学习的方法能够比精心特征工程结合传统机器学习的方法效果更优,而这正是深度学习强大威力之所在,也是驱动这一波人工智能繁荣的原因之所在。

诚然,BiLSTM-CRF是非常经典有效有用且强大的模型,但一山还有一山高,读过珠峰书的读者知道,后面还有更好的深度学习模型。



参考材料



  • 王文广. 知识图谱:认知智能理论与实战[M]. 北京:电子工业出版社, 2022: P

  • Paddle官方文档:

  • PaddleNLP 官方文档:

  • 本文代码及数据可从https://github.com/wgwang/kg-book上获取


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存